Skip to content

wpfacts, codegen: arrays held in class fields read under Rep::Arr, and field read calls inside the floor (P4 lever 1, PR-C3) - #14402

Merged
proggeramlug merged 25 commits into
mainfrom
perf/p4-arr-fields
Nov 10, 2026
Merged

proggeramlug merged 25 commits into
mainfrom
perf/p4-arr-fields

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

DESIGN-p4l1 rev 2 §3 (the field's rep, FactsField.rep, FieldProof.array as the join over the class set), §4.2 (the one tier, here for field reads and for b1, this.at(sq) = this.board[sq] ?? 0 behind a method), §2.8 (b1 stays export-time), §5.1-5.6 and §8's PR-C3 row, with REVIEW-p4l1's conditions for C3: finding 8 (hard.is_none(), join not meet, both already in PR-B's FieldFacts::rep), finding 13 (the sites that are not in the floor) and "the passed-pawn interval from the dump first".

Branch head: 3e926635b5. The branch merges current origin/main (482d9c9, with C2 #14394, P3 B3 #14395 and N6 #14396), by merge, not rebase; it merges cleanly with ba9234e.

What changes

  • The field's rep reaches codegen (FactsField.rep, from wpfacts FieldFacts::rep, which PR-B computed with no consumer). A field has Arr when it is definite, a data field, its template is neither escaped nor hard, and every value its field node holds, over every token of the template, is a plain program Array.

  • FieldProof.array is the join of that rep over the receiver's class set, None when some class's field is not Arr, and for a guarded or possibly absent read. wpfacts_access::field_array is the same join for a read however it is lowered (the field's value does not depend on the slot proof): an unchecked proven receiver, no interpreted code.

  • One query for a receiver's rep (proven_array::receiver_rep): a local, parameter or module constant (BindingFacts::rep, C2), or a field read (FieldProof.array). A direct this.f[i] read then takes C2's tier unchanged: the key's interval at the site (element_keys) against the floor, the number test by number_element_reads.

  • b1 inside the floor (§2.8). The method body's read joins every caller's key, so its own facts keep the bound, the hole test and the ??. wpfacts' FieldReadCall gains:

    • array: arr_rep over every copy (inlined, and one per summarized instance) of the body's element-read receiver, which is what the read reads from;
    • in_floor: array.dense, and this call's own key interval (call_arg_ranges) inside [0, array.floor).

    It is computed at export from the template's hole_free bit (PR-B's fused fill), the length floor and the call's key. It does not go through C1's ElemUndef, so it holds for an array that is stored into.

  • The in-floor call in codegen (lower_call/field_read_call.rs). When the call is in the floor and the field read is Arr, dense, with a floor, the call is: the index as an int32, the field load, and the tier's in-floor read (proven_array::lower_in_floor): one and, the forwarding test when the storage can grow, the load at h + 24 + 8*i. With Number elements (rep.elem is F64 or I32) the value is never nullish and ?? L is the value. With Boxed elements and a ?? L the call keeps the read it had (a stored null or undefined is a present element).

  • Not in the floor (make's at(captureSquare), set's at(square)): the rebuilt read this.f[k] ?? L goes through the tier as any field read does: forwarding test, idx <u capacity, load, hole compare, ??, cold arm.

emitted piece b1 call in the floor field read outside the floor main (C2)
pointer band test, type test none none both
forwarding test when !fixed when !fixed in the type word test
bound none idx <u capacity idx <u ek_prefix, then length
slot address h + 24 + 8*idx h + 24 + 8*idx (floor > 0) from the header
hole test none one TAG_HOLE compare one
?? L none (Number elements) kept kept

The per-site verdicts (the dump; chess.ts)

PERRY_FACTS_DUMP prints one line per field read call: # field_read_call module= call= field= key=[lo,hi] rep= in_floor=. Chess has 35 such calls, all on Chess.board = arr<f64,128,dense> (growable: the store keys reach 255, so the forwarding test stays). 27 are in the floor, 8 are not:

site key at the call verdict design §6.2
attacked:554/558/564 at(source) [0,127] in the floor in
generatePseudo:596/604/612 at(to), at(capture) [0,127] in in
generatePseudo castling at(home±k) [1,113] … [6,118] in in
make:648 at(from) [0,127] in in
negamax/quiescence at(moveTo(move)) [0,127] in in
evaluate:389 shield [0,127] in in
make:652 at(captureSquare) [-16,143] not in not in (finding 13)
set:464 at(square) [-128,255] not in not in (finding 13)
generatePseudo:599 at(to + direction) [-16,143] not in not in (finding 13)
evaluate:369 passed pawn at(r*16+f) [0,255] not in "if P1 gives [0,119]": it does not
evaluate:342/354 at(square) [0,255] not in counted in
generatePseudo:590 at(from), 741 at(square) [0,255] not in counted in

The passed-pawn interval is settled: [0,255], not in the floor, so evaluate is at the low end of the design's range, and below it, because two more site classes the design counted in the floor are not. Both are the range analysis, not this PR:

  • r*16 + f with r, f in [0,7]: the sum of two operands that both grow during the solve is widened past its hull. A probe with one method per shape (same binary, same dump) gives: at(r*16) [0,112] (exact); at(r + f) [0,127] (hull [0,14]); at(r*16 + f) [0,255] (hull [0,119]) when both loops count up, and [0,119] (exact) when r counts down from 7. The rungs are model::range::widen's ladder (7, 127, 255). Which growth takes the sum past 127 is not investigated.
  • for (let square = 0; square < 128; square++) { if ((square & 0x88) !== 0) { square += 7; continue; } … this.at(square): gen/narrow.rs ends the narrowed reads at the body's first write of the counter in program text, also when that write is in a branch that always leaves (continue), so the read after the if sees the counter's whole interval, [0,255]. The same probe: with square += 7; continue; the key is [0,255], with a bare continue; it is [0,127].

Those reads take the outside-the-floor form (saving about half of the in-floor form). Follow-up, with its own census: see below.

Check mode, switch, sabotage

  • PERRY_VERIFY_FACTS=1.
    • A field read whose proof has array: Some(r) tests the word it loaded (array_rep::witness_value, Fact::ArrayRep): heap band, GC_TYPE_ARRAY, not forwarded when fixed, length >= floor and capacity >= floor.
    • An in-floor call is tested where it loads (array_rep::witness_read): the claims above, no element descriptor, front offset 0, the key is below the call's floor, the slot is no hole and holds an element of rep.elem. b1's own witness stays: the real call runs beside the read and the two values must be the same bits.
  • PERRY_FACTS_ARRAY_FIELDS=0: FieldFacts::rep is Boxed and no call is in the floor, which is C2's build (before the merge of main, the off arm's chess counts are C2's on arm to 0.1 M). It is a compile-cache input (compile_env.rs), and array_fact_switches_are_cache_inputs lists it.
  • Sabotage arms, each with an S0 fixture that exits 0 when correct and 86 ("array rep") when sabotaged:
    • arrfield: a field's rep ignores the null/undefined its node also holds (array-field-negative.ts: the witness at the field read reports the null word);
    • arrb1: every call into a dense floor is in the floor, whatever its key (array-b1-negative.ts: keys ep - 16 and ep + 16 on a 16-element board; the tier's witness reports the key).

Results (qb9, instructions:u)

Build 9b04629446 (the head's code: the three later commits change two unit-test files and a comment in a parity fixture). On vs PERRY_FACTS_ARRAY_FIELDS=0 on the same binary, two runs each, checksums equal in every run.

workload on (M) off (M) change saved (M) design estimate (M; % of the design's main)
search 250000 30 754.4 33 147.9 −7.22% 2 393.5 3 048 to 3 469 (−8.1 to −9.2%)
perft 4 710.4 772.6 −8.05% 62.2 72.2 (−8.0%)
evaluate 100000 2 847.6 3 095.1 −8.00% 247.5 430 to 643 (−13.6 to −20.3%)
legal 10000 1 073.2 1 131.3 −5.14% 58.1 67.1 (−5.3%)

Runs: search 30 754.3 / 30 754.4 on, 33 147.9 twice off; the other three identical between runs. Before the merge of main (build 8766d29dbe, without P3 B3 and N6): search 31 998.9 / 34 341.2 (−6.82%), perft 747.0 / 807.6 (−7.50%), evaluate 2 870.1 / 3 117.5 (−7.94%), legal 1 110.7–1 111.1 / 1 167.5 (−4.87%); that off arm equals C2's on arm to 0.1 M.

Search, perft and legal are at 79%, 86% and 87% of the design's low estimate in instructions; evaluate is at 58% of it. The gap is the interval verdicts above: with evaluate:342/354/369 outside the floor the design's own per-read numbers give 0.606 M × 16 + 39.59 M × 8 = 326 M for evaluate, against 247.5 M measured.

The board read in attacked (this.at(source) === color * 2 after the knight delta), from the disassembly of both arms: with the fact off the hot path is 20 instructions up to the load (field load, band, type word, ek_prefix bound, slot math, load), followed by the ?? test; with it on it is 9, and there is no ??:

mov    rdx,[rip+…]            ; field load
mov    rcx,r9
vmovq  xmm0,[rcx+rdx+0x200010]
vmovq  rcx,xmm0
bzhi   rcx,rcx,r12            ; and POINTER_MASK
test   BYTE PTR [rcx-0x7],0x80 ; forwarded?
jne    cold
mov    eax,eax
vmovsd xmm0,[rcx+rax*8+0x18]

Chess.attacked$sig is 986 instructions with the fact on and 1 169 off. Tier blocks per function (on / off), read from the IR: attacked 7 in-floor reads and no ?? (off: 7 ??, 14 guard chains); make 2 forwarding tests, 1 capacity bound, 1 hole test, 1 ?? (at(from) in the floor, at(captureSquare) not; off: 2 ??, 4 guard chains); generatePseudo 15 reads, 3 of them bounded (off: 30 guard chains, 15 ??).

gate result
chess IR on vs off: 1 of 2 modules differs (the engine)
hello / hello2 byte-identical on/off (md5 dc07ba3f94af, 9bb2e64cc5e9); callgrind Ir 127 145 and 623 941, the same in all 3 runs of each arm
parity fixtures, node v26.12.1 18 fixtures (the 3 new ones, C2's 6, PR-A/B/C1's array fixtures, for_of_literal, copy_holes_facts) × on, fields off, reps off, check mode, PERRY_GC_MOVING_LOOP_POLLS=1, PERRY_GC_STRESS=7: 108 of 108 MATCH
parity IR census, on vs off 29 of 2556 programs differ in IR; no compile-rc difference; 11 OUTDIFF, every one with identical IR in both arms (5 programs that compile in neither arm, and timing/Date/random outputs)
vf, PERRY_VERIFY_FACTS=1 n=1538, json=0, abort=0, rc86=0, crc!=0=2 (the known pair: test_gap_s8b_computed_fields, test_gap_10554_fn_identity_own_module)
S0 75 executable controls pass; array-field-negative and array-b1-negative: correct 0, false 86 ("array rep"); array-dense/elem/lazy/front/undef-negative false arms 86
chess in check mode perft, legal, search and evaluate rc 0, no misses; 462 array-rep witness rows
lost sites, tssrc on vs off: lost 0, gained 0 (24 552 proven in both)
suites (release, qb9) on the head 3e926635b5: perry-codegen 2850 passed, perry-wpfacts 2221 passed, 0 failed (the build commit's run had one wrong test expectation in each, fixed in the head). perry-hir 881 passed; perry-wpfacts num_seeds_audit 2 passed (no new prim::NUM use); perry-runtime 6641 passed, 0 failed. perry compile_env 4 passed (with array_fact_switches_are_cache_inputs), 1 failed: codegen_env_vars_are_build_cache_inputs ("shared list must be sorted and unique"), the failure main already has (FOLLOWUPS.md)
-D warnings perry-hir, perry-wpfacts, perry-codegen, perry: 0 errors
scripts/check_file_size.sh passes, allowlist unchanged
rustfmt own files and hunks only

Size (every executable section; on vs off)

.text, perry_js_text, .init, .plt, .plt.got, .fini.

program on off change .text alone perry_js_text alone
chess 7 597 109 7 606 601 −9 492 −384 −9 108
three.js skin 25 280 342 25 280 342 0 (every section equal) 0 0
tssrc 63 935 330 63 935 449 −119 0 −119
Supabase CLI 217 694 621 217 694 621 0 (every section equal) 0 0

No section of any program grows. Per symbol: skin's symbol table is identical on and off; tssrc differs in one function (addMissingAwait.ts getDeclarationSiteFix, 2 964 B on against 3 083 B off); Supabase differs in one closure (functions/new/new.handler.ts closure 31, 42 815 B on against 42 808 B off: +7 B, not looked into, inside the section's padding since the section sizes are equal).

Departures from the design, and what is not done

  • Board reads lose the hole test and the ?? through b1's export-time in_floor, not through ElemUndef. C2's departure stands for direct reads: C1's ElemUndef takes the hole's undefined from hole(t), which every index store marks, so a direct read of a stored-into array (whitePawns[file], and a direct this.board[i]) is Number-or-undefined in the facts and keeps its ?? and number test. Chess reads its board only through at, so that does not cost C3 anything. The solver change (a store of a non-undefined value into a slot below the dense floor creates no hole) is not in this PR.
  • The Number claim of an in-floor call is rep.elem, not number_element_reads (C2's rule for direct reads). The body's read node holds the undefined of the other callers' keys, so number_element_reads can never say Number for it. It is the same solver fact (the element nodes hold only Numbers), tested per read by the witness's element test, and P3 B3 is merged. Direct reads keep C2's rule.
  • FieldReadCall.in_floor reaches codegen as the floor (FactsFieldReadCall.in_floor: Option<u32>), not as a bool. The floor wpfacts proved the key below is over the Arrays the method's read can read; codegen's FieldProof.array is over the receiver binding's classes. They agree in every program measured, but the witness tests the key against the floor the claim was made for.
  • FieldReadCall.array is the rep of the method's read receiver, not FieldFacts::rep of the call's receiver classes: wpfacts has the receiver node there, not codegen's class set. Codegen still requires FieldProof.array (with finding 8's hard test) for the same read.
  • An in-floor call over Boxed elements with a ?? L keeps the read it had (bound, hole test, ??). A reader without a default (return this.f[k]) is in the floor for any element kind.
  • A call whose index reads a field (this.at(this.ep - 16)) is not a b1 call in codegen (b1's admission wants an inert index: main's rule), so wpfacts' in-floor verdict for it has no consumer.
  • Not in the floor because of the key interval (the table above): evaluate:342/354/369, generatePseudo:590 and 741. Follow-ups in the range analysis, each a solver change with its own census: (1) the widening of a sum of two growing operands, (2) a counter write in a branch that always leaves does not end the narrowed reads after the branch. Per the design's per-read numbers (in the floor 16, outside 8) they are worth about 8 instructions on 39.6 M reads of the evaluate workload (about −10%) and on the 80.1 M evaluate reads of search (about −1.9%), plus generatePseudo's at(from). Not measured.
  • Fixtures. 11 (field reassignment with another floor, K-FS3 this[name] = other) and the negative controls (stored undefined, a null field, no fused fill, a sparse store past the end, delete of an element, a length store, a deleted field, an overriding subclass, string elements, fraction/NaN/-0 keys) are test_array_reps_fields.ts; 13 is …_fields_gc.ts; 14 is …_fields_growth.ts (push in the reading loop, and one store far past the capacity between reads).
  • IR assertions are unit tests on injected facts (proven_array_tests.rs: the in-floor call of a growable and of a fixed board, the call outside the floor, the direct field read, a Boxed field changes nothing, the join). The chess claims above are read from its IR and disassembly by hand.
  • Mac: not built. One cargo check --tests ran there on an early commit and stopped at a test-only error in perry-wpfacts before reaching perry-codegen; nothing later was compiled on the Mac.

Ralph Küpper added 25 commits November 9, 2026 20:17
An Array's length interval keeps its birth `lo`; growth raises only `hi`
(solve/arr_len.rs, DESIGN-p4l1 §2.5):

- an index store with key interval [a, b], b >= 0, raises hi to
  min(b + 1, u32::MAX);
- a store whose keys are all negative changes nothing (a negative key is
  a named property in the separate bag);
- an append (push, an element store at a non-index site) raises hi to
  u32::MAX: a rule runs once per token, not per runtime append;
- reverse/copyWithin make the length ⊤ once hi > lo (Cons::LenPermuted,
  a monotone rule on the length node); the generic `.call` forms make it
  ⊤ outright;
- every shrinking event stays ⊤.

A `new Array(k)` birth length is recorded only for
k <= MAX_FRESH_DENSE_ARRAY_LENGTH, so the birth guarantees capacity >=
floor, and the element read's consumer-side capacity test is removed;
the witness keeps checking capacity >= len. The inline literal header
goes through perry_abi::array_birth_header, which asserts capacity >=
length. A runtime test classifies every `capacity` write (incl.
storage.rs's empty-queue reset); the stale expando comments are fixed.

PERRY_FACTS_ARRAY_FLOOR=0 restores the previous transfer (a compile-cache
input). Sabotage arms arrfloor, arrappend, arrcap (alias densebirth) and
arrfwd with S0 fixtures; node-exact parity fixtures for growth, shrink
and permutation.
A binding or class field that holds only plain program Arrays gets its own
representation, Rep::Arr(ArrRep { elem, floor, dense, fixed })
(DESIGN-p4l1 §2.1-2.6). No consumer yet.

- TmplKind::Arr gains `plain`: set only at an array literal, a spread
  literal and new Array/Array() (site_new_array). JSON arrays, stand-ins and
  every runtime-born array are not plain.
- R1: the per-site template key holds `plain`, so a call whose callee holds
  both Array and JSON.parse births one template per kind; only a plain birth
  ever makes or finds a plain template, and only it carries the new Array
  birth length.
- R2: BirthKind::Array carries `plain` beside `hole_free`, so a body
  packet's birth names it. The fused-fill mark lives in arr_len_at_site's
  value (SiteBirth { len, hole_free }), not in a new map.
- R3: ArrRep carries `fixed` (every token's length interval is one value:
  never grows, never forwarded), joined by AND, so a field or a signature
  slot keeps or drops it with the rest and no consumer asks const_length.
- new Array(k).fill(v) (one argument, the fresh receiver) is born
  hole-free: the intrinsic lock makes it the native fill.
- Rep loses its derived order; Rep::join is one full table (F64/I32 join
  Arr to Boxed) and Rep::encoding() gives the slot encoding (Arr: Boxed).
  P2's local join is deleted; Signature::is_boxed, admits and every
  codegen signature decision go through the encoding, so an Arr slot never
  makes a function typed or a site untyped.
- FieldFacts::rep: Arr of the field over every token when definite, data,
  not escaped and not hard; joined (not met) over classes.
- Dump: `# arr_rep` per binding, `FACT FR rep` per field.
- Check mode: Fact::ArrayRep at an element read of an Arr local (band,
  GC_TYPE_ARRAY, not forwarded when fixed, length and capacity >= floor,
  no hole below the floor when dense, element kind of a present slot).
- PERRY_FACTS_ARRAY_REPS=0 turns it off (a compile-cache input). Sabotage
  arms arrdense, arrelem, arrlazy with S0 fixtures; parity fixtures for
  births/fused fill and lazy/runtime-born arrays; wpfacts unit tests.
…EPS=0

With Rep::Arr on, the array rep witness reports the false length of the
densebirth/arrcap arms first (a correct detection under another
predicate); each older arm now reaches the witness it was written for.
# Conflicts:
#	crates/perry-codegen/src/expr/index_get.rs
#	crates/perry-wpfacts/src/lib.rs
#	crates/perry-wpfacts/src/model/cons.rs
#	crates/perry/src/commands/compile/program_facts.rs
NUM_SEEDS.md: rows for the two bindings.rs reads and the arrlazy sabotage seed (PR-B), and for the signatures.rs read (P2).
# Conflicts:
#	crates/perry-codegen/src/expr/index_get/array_rep.rs
#	crates/perry-wpfacts/src/lib.rs
#	crates/perry-wpfacts/src/model.rs
#	crates/perry-wpfacts/src/tests/array_reps.rs
#	crates/perry/src/commands/compile/compile_env.rs
#	crates/perry/src/commands/compile/compile_env/tests.rs
#	crates/perry/src/commands/compile/program_facts.rs
#	scripts/test-verify-facts-s0.py
# Conflicts:
#	crates/perry-wpfacts/src/dump.rs
@coderabbitai

coderabbitai Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

Explain Change →

Caution

Review failed

The pull request is closed.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@proggeramlug
proggeramlug merged commit 821bf58 into main Nov 10, 2026
93 of 102 checks passed
@proggeramlug
proggeramlug deleted the perf/p4-arr-fields branch November 10, 2026 07:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant